A from-zero, ultra-detailed guide to GraphQL for intermediate iOS developers. Assumes no prior knowledge of GraphQL but assumes solid fluency in Swift, Xcode, async/await, SwiftUI, and REST APIs.
Part I — Understanding GraphQL (Concepts Only, No Apollo Yet)
Part II — Apollo iOS, The Tool
Part III — Building Your First Screen
Part IV — Mutations
Part V — The Cache, Where the Magic Lives
Part VI — Production Concerns
Part VII — Architecture and SwiftUI
Appendix
This whole part has zero Swift code. We're going to build the conceptual model of GraphQL first, because if you skip this and jump straight into Apollo, all the generated Swift types and cache mechanics will feel like noise. Once GraphQL itself clicks, the iOS layer becomes mostly mechanical.
To understand why GraphQL was invented, picture a screen you've definitely built before: a user profile page in a social app. It needs the user's name and avatar at the top, their bio below, a count of their followers, and a horizontally scrolling row of their three most recent posts.
If you build this with REST, you make some calls something like:
GET /api/users/42 → name, avatar, bio, joined_at, ...
GET /api/users/42/followers/count → just a number
GET /api/users/42/posts?limit=3 → array of posts
Three round trips. Each round trip is roughly 150-300ms on a good cell network and easily 500-1000ms on a bad one. The user looks at a half-loaded screen waiting for the slowest one. You add concurrency to fire them in parallel; you still wait for whichever finishes last. Then on top of that:
{ "count": 1247, "trend": "up", "weekly_delta": 12, ... } — extra fields that exist for analytics dashboards, not your screen.That's overfetching: getting more bytes than you'll display. It costs network time, parsing time, and battery.
Now imagine you ship the app, and a few months later you decide to add a "verified" checkmark next to the username. The backend team adds an is_verified field to /users/:id. You update your iOS Codable struct to include is_verified: Bool, but old versions of your app — still in the wild on the App Store — never asked for it. They don't break, but they also can't show the checkmark. To fix existing installs, you ship a new app build. The cycle takes weeks.
GraphQL was built (at Facebook, around 2012) to solve exactly these problems for mobile apps. The shift it makes is one sentence long but takes a while to internalize:
The client tells the server what data it wants, and the server returns exactly that — no more, no less.
If your profile screen needs name, avatar, followerCount, and posts.title for the latest three, you write a single query that asks for those fields, in that shape, in one request. The server runs it through its type system, fetches the data, and sends back JSON that mirrors your request shape exactly. One round trip. Zero overfetch.
That's it. That's the core idea. Everything else in this tutorial — schemas, fragments, the cache, Apollo's code generation — is either machinery to make this work in practice, or a consequence of it.
A few clarifications about what GraphQL is and isn't, while we're calibrating expectations:
GraphQL is not a database. It doesn't replace Postgres or anything else. It sits between your client and whatever data sources the server uses; the GraphQL server's job is to take a query, route the work to the underlying data sources, and assemble a response.
GraphQL is not a transport protocol. It typically rides on top of HTTP (often a single POST /graphql endpoint) for queries and mutations, and WebSocket for subscriptions. But the GraphQL spec doesn't require any specific transport. You could ship GraphQL over carrier pigeons if you wanted.
GraphQL is not REST's replacement in every situation. For simple, mostly-read APIs with one or two clients, REST is often fine. GraphQL shines when you have many clients (web, iOS, Android, watchOS), each with different data needs, all hitting the same backend.
GraphQL is strongly typed. Every field on every type has a declared type, and the server enforces it. This is critical context for what's coming: when you generate Swift code from a GraphQL schema, that strong typing carries through. Your iOS code never has to guess whether a field is String? or String — the schema says.
Why iOS especially benefits. App releases are slow (App Review, gradual rollout, users who never update). Network is unreliable and expensive. Screens are heterogeneous (the same
Usershows up on a profile screen, a comment header, a follower list — each wants a different slice). All three of these are GraphQL's sweet spot. If you've ever built a multi-screen app and felt frustration at how poorly REST scales to it, you'll feel the relief of GraphQL fast.
Let's look at a real query and read it like we've never seen one before. Forget Apollo, forget Swift — just GraphQL.
query GetCountry($code: ID!) {
country(code: $code) {
name
capital
currency
continent {
name
}
}
}
Every character in here means something. We'll go word by word.
query — this single keyword tells the server "what follows is a read operation." There are three operation types in GraphQL: query, mutation, and subscription. We'll cover the others in Chapter 4. query means "I'm not changing anything, just reading."
GetCountry — the operation's name. This is yours to choose. The name doesn't affect what runs on the server, but it's important for three reasons. First, server logs and observability tools tag requests by operation name; an unnamed query just shows up as anonymous and you lose visibility. Second, code generators (including Apollo's) use the name to derive Swift type names — GetCountry becomes GetCountryQuery. Third, if you ever put two operations in one document, names are how you tell them apart. Always name your operations. The convention is PascalCase, verb-first: GetCountry, CreatePost, DeleteComment.
($code: ID!) — the operation's variable declaration list, in parentheses, like a function signature.
$code — the variable's name. Variables in GraphQL are prefixed with $. This is purely syntactic; it lets the parser distinguish variables from field names.: — separates the variable name from its type. Read it as "of type."ID — the type. ID is one of GraphQL's five built-in scalar types (Int, Float, String, Boolean, ID). ID is just a string under the hood, but the schema author signals "this is meant to be an opaque identifier, not human-readable text." Servers serialize it as JSON string.! — the non-null marker. ! after a type means "required, never null." No ! means "may be null."So $code: ID! reads as: "I will provide a variable called code, which must be a non-null ID." If you forget to send the variable when executing this query, the server rejects the request before even running it.
{ ... } — the selection set. Curly braces in GraphQL enclose the fields you want fetched. Every non-leaf field needs one. There's no implicit "give me all fields" — you spell out what you want, always.
country(code: $code) — a field call. country is a top-level field on the schema's Query type (we'll explain Query in Chapter 3). It takes one argument named code, and we're passing it the value of the $code variable.
A note on argument names: code: $code looks redundant, but it isn't. The left code is the argument name as defined on the field (set by the schema author). The right $code is our variable name. They happen to match here. We could just as well write country(code: $countryCode) if we'd named our variable $countryCode.
name, capital, currency — leaf fields on the Country type. Each one is a scalar value, so they don't need their own selection sets. The query is asking: "for the country I just looked up, give me its name, capital, and currency."
continent { name } — continent is not a leaf. On the Country type, continent returns another object — a Continent. So we need a selection set for it. Inside, we ask for just the continent's name. We could ask for more (code, countries for sibling countries, etc.), but we don't need them on this screen.
When the server runs this query with code = "CA", the response will look like:
{
"data": {
"country": {
"name": "Canada",
"capital": "Ottawa",
"currency": "CAD",
"continent": {
"name": "North America"
}
}
}
}
Look closely at the response. Now look back at the query. They're the same shape. The query is a tree of field names; the response is the same tree with values filled in. That isomorphism is GraphQL's central design move and it's what makes the language pleasant to write and to consume.
A few subtler things this example doesn't show, but you should know exist:
Aliases. If you wanted to fetch two countries in one query, you'd hit a name collision: country(code: "CA") { name } and country(code: "US") { name } both produce a field called country in the response. To disambiguate, you use an alias:
query GetTwoCountries {
ca: country(code: "CA") { name }
us: country(code: "US") { name }
}
Response:
{ "data": { "ca": { "name": "Canada" }, "us": { "name": "United States" } } }
The ca: and us: rename the fields in the response. Aliases are also useful when you want the same field with different selection sets, or different arguments.
Comments. GraphQL uses # for comments:
query GetCountry($code: ID!) {
country(code: $code) {
name # display name in user's locale
capital
}
}
Whitespace. GraphQL doesn't care about whitespace between tokens. You could put the whole query on one line. Don't, obviously — but the parser doesn't care.
__typename. Every type in GraphQL exposes a magic field called __typename (with two underscores). It returns the runtime concrete type name as a string. It's hugely useful when you have unions or interfaces (Chapter 14 details), and Apollo uses it under the hood for caching. You'll see it appearing automatically in Apollo's generated requests.
A GraphQL server publishes a schema: a complete, machine-readable description of every type, every field, every argument, every operation it accepts. Think of it as the server's exhaustive type contract — the thing your Codable User struct should ideally have been generated from, but never was.
The schema is written in a small, declarative language called SDL (Schema Definition Language). It looks a bit like Swift protocols mixed with TypeScript interfaces. Here's a small but realistic schema:
"""
A real-world country.
"""
type Country {
code: ID!
name: String!
capital: String
emoji: String!
currency: String
languages: [Language!]!
continent: Continent!
}
type Continent {
code: ID!
name: String!
countries: [Country!]!
}
type Language {
code: ID!
name: String
native: String
rtl: Boolean!
}
type Query {
country(code: ID!): Country
countries(filter: CountryFilterInput): [Country!]!
continent(code: ID!): Continent
}
input CountryFilterInput {
code: StringQueryOperatorInput
continent: StringQueryOperatorInput
}
input StringQueryOperatorInput {
eq: String
in: [String!]
}
Let's go feature by feature.
type Country { ... } defines an object type named Country. Object types are the bread and butter — almost everything in your schema is one. The body declares fields: each field has a name, a colon, and a type.
The lines like name: String! declare that every Country value has a name field, and that field is a non-null String. Read the : as "of type" and ! as "required."
GraphQL ships with five scalar types that any server understands:
| Scalar | Meaning | JSON form |
|---|---|---|
Int |
32-bit signed integer | number |
Float |
Double-precision float | number |
String |
UTF-8 string | string |
Boolean |
true/false | boolean |
ID |
Opaque identifier (string-shaped) | string |
ID is interesting because it's a string semantically — but the schema author is signaling intent: "this is a primary key, not human-readable text. Don't display it." Apollo and other tools may use ID fields for cache keys (we'll see this in Chapter 20).
Servers can define their own scalars beyond the built-ins. Common ones:
scalar DateTime
scalar URL
scalar UUID
scalar JSON
These are placeholders — the schema declares them, but the server defines how they serialize (typically DateTime is an ISO-8601 string, URL is a string, JSON is an arbitrary nested structure). When you pull the schema into your iOS client, you need to tell Apollo how to map these scalars to Swift types like Date and URL. Chapter 30 covers this in detail.
This bears repeating because it's the #1 thing iOS devs miss when first reading schemas:
String! — non-null. The server guarantees this is never null. In Swift, this becomes String (no optional).String — nullable. The server may return null. In Swift, this becomes String?.That single ! carries a load-bearing semantic guarantee. Backends that mark too many fields nullable produce miserable Swift code with optionals everywhere. Backends that nail down nullability produce ergonomic code. If you have any input on schema design, fight for accuracy here.
It applies to lists too:
[Language!]! — non-null list of non-null Languages. Always an array, never null elements. → Swift: [Language][Language!] — nullable list of non-null Languages. The list may be null, but if it's non-null, no element is null. → Swift: [Language]?[Language]! — non-null list of nullable Languages. Always an array, but elements may be null. → Swift: [Language?][Language] — nullable list, nullable elements. → Swift: [Language?]?Memorize this table. You'll read it instinctively after a week.
The schema designates three special types — Query, Mutation, and Subscription — as the operation roots. They aren't structurally different from any other object type; they're just the entry points. Every field on Query is a thing the client can ask for at the top of a query operation. Every field on Mutation is a thing the client can call to change data. Every field on Subscription is a stream the client can subscribe to.
type Query {
country(code: ID!): Country
countries(filter: CountryFilterInput): [Country!]!
}
This declares two top-level "entry points": country (takes an ID!, returns a nullable Country — null if not found) and countries (takes an optional filter, always returns an array). When you write query GetCountry { country(code: "CA") { name } }, the server starts at the Query type and resolves country from there.
Most schemas have all three operation roots. Some only have Query (read-only APIs). The Countries API we'll use in Part II is read-only, so it has no Mutation or Subscription.
When a field takes a complex argument — not just a scalar but a structured object — the schema uses an input type:
input CountryFilterInput {
code: StringQueryOperatorInput
continent: StringQueryOperatorInput
}
input is like type but limited: input types can't have other input types nested inside cyclically, can't have computed fields with arguments, etc. They're meant for "a bag of values you pass in." On the client, input types map to Swift structs.
enum PaymentStatus {
PENDING
COMPLETED
REFUNDED
FAILED
}
Enums are exactly what you expect. They're closed sets of named values. Apollo generates a Swift enum on the client. If the server later adds a new case (DISPUTED) and your old client doesn't know about it, Apollo's generated enum exposes a __unknown(String) case so the client doesn't crash.
Interfaces are like Swift protocols — types declare they "implement" an interface and gain its required fields:
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
type Post implements Node {
id: ID!
title: String!
}
A field returning Node could be a User or a Post at runtime. To select fields beyond what the interface declares, you use inline fragments (Chapter 14).
Unions are sets of unrelated types:
union SearchResult = User | Post | Comment
Same idea as interfaces but without shared fields. You discriminate with __typename and inline fragments.
Every now and then you'll see something with @:
type User {
email: String! @deprecated(reason: "Use emailAddress instead")
emailAddress: String!
}
Those are directives — annotations on schema or query elements. @deprecated is built in. Servers can define their own. You won't write directives often as a client, but you'll see @include and @skip for conditional fields:
query Profile($withPosts: Boolean!) {
me {
name
posts @include(if: $withPosts) { title }
}
}
A schema is just a long text document with these elements. Servers expose it (typically via "introspection" — see Chapter 6) so clients can fetch it programmatically. Apollo's code generator reads this document at build time and produces Swift types for everything.
That's the schema. Re-read this chapter once before moving on; everything that follows leans on this.
We've mentioned query, mutation, and subscription repeatedly. Here's the full picture.
query GetUser { me { name } }
Queries are the workhorse. They read data. They're side-effect-free. The server may execute fields in parallel for efficiency. Apollo can cache them. You'll write 90%+ of your operations as queries.
mutation UpdateAvatar($url: URL!) {
updateMyAvatar(url: $url) {
id
avatarURL
}
}
Mutations change something on the server. They look syntactically identical to queries except for the keyword and which root type they hit. Crucial difference: the GraphQL spec says fields on Mutation execute serially, in order. Fields on Query may execute in parallel. So if you put two mutations in one document, they run one after the other. That matters for things like "create user, then create their first post" — the second can depend on the first.
Mutations also typically return the changed object — so you can ask for the new state in the same round trip:
mutation Like($postId: ID!) {
likePost(id: $postId) {
id
likeCount # the new count after liking
isLikedByMe # true now
}
}
This is more than a convenience. The fact that the mutation returns the affected object is what lets Apollo's cache update other screens automatically (Chapter 25). If your backend's mutations return only Bool ("ok!"), you lose this property. Push for backend mutations that return real objects.
subscription PostAdded {
postAdded {
id
title
}
}
Subscriptions are long-lived. The client opens one (typically over WebSocket), and the server pushes events whenever they happen. Each event is a normal GraphQL response — same data envelope, same selection set evaluation. They feel like a stream of mini-queries.
Use cases: chat messages, live notifications, presence indicators, real-time dashboards. Don't reach for them just because they're cool — they require running a WebSocket, which has its own complexity (auth, reconnects, scaling). For "refresh every 30 seconds," polling is often simpler.
A .graphql document is a file. It can contain multiple operations and any number of fragments (Chapter 14). When you execute, you point at one specific operation by name:
# OperationsForFeed.graphql
query Feed { posts { id title } }
mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } }
fragment PostBasics on Post { id title createdAt }
In iOS-land with Apollo's code generation, each operation becomes its own Swift type. So you don't "select" by name in code — you instantiate the right type and call it.
Before we get into Apollo, let's pull back the curtain. People talk about GraphQL like it's exotic, but it isn't — it's JSON over HTTP, with a particular convention.
When your client executes a query, it sends a POST request to the GraphQL endpoint. The body is a JSON object with two keys: query (the GraphQL document, as a string) and variables (an object with the variable values).
curl -X POST https://countries.trevorblades.com/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "query GetCountry($code: ID!) { country(code: $code) { name capital } }",
"variables": { "code": "CA" }
}'
The server responds with a JSON object that has (at minimum) a data key:
{
"data": {
"country": {
"name": "Canada",
"capital": "Ottawa"
}
}
}
That's it. That's the entire wire protocol. There's no special encoding, no binary anything, no magic.
A few extra details worth knowing:
The errors key. If anything went wrong, the response also has an errors array:
{
"data": null,
"errors": [
{
"message": "Country not found",
"path": ["country"],
"extensions": { "code": "NOT_FOUND" }
}
]
}
data and errors can both be present at once — that's the partial-success case (Chapter 29).
HTTP status codes. A successful GraphQL response is almost always HTTP 200, even if it contains errors. The HTTP layer says "request reached the server, parsed correctly." The GraphQL layer (the errors array) describes what went wrong with the operation. This trips up REST veterans constantly. A 200 with errors: [...] is normal GraphQL.
Headers. Just Content-Type: application/json. Auth, if needed, goes in Authorization: Bearer ... like any other API. You can add custom headers freely.
GET requests. The spec also allows GET, with query and variables URL-encoded as query parameters. Used for cacheability via HTTP cache. Less common — almost everyone uses POST.
Persisted queries. A production optimization where the client sends only a hash of the query ({ "id": "abc123", "variables": {...} }) and the server resolves it from a registry. Reduces bytes on the wire and prevents arbitrary queries from hitting prod. Apollo iOS supports this; it's an optional layer (Chapter 35).
That's all the wire stuff. Spend a minute running the curl command above against the Countries API in your terminal — see the response come back. Once you internalize "it's just JSON over HTTP," GraphQL stops feeling like a black box.
Here's a thing GraphQL has that REST doesn't: the API documents itself.
Every GraphQL server can be queried for its own schema. This is called introspection. There's a built-in query (__schema) that returns the full type system. Tools use this to provide IDE-like exploration of any GraphQL API.
The most famous of these tools is GraphiQL ("graphical," with an "i") — a free web-based GraphQL IDE. Most GraphQL endpoints serve a GraphiQL UI when you visit them in a browser. Try it now: open https://countries.trevorblades.com/ in your browser. You'll see a split pane: query editor on the left, response on the right, and a "Docs" sidebar on the far right.
A few things to do in GraphiQL once you're in:
Click "Docs" at the top right. You can browse every type, every field, every argument. The documentation comes from the schema's docstrings (the """...""" blocks in SDL). For learning, this is gold.
Start typing a query. GraphiQL autocompletes field names, validates against the schema in real time, and tells you the type of every field you hover over. Try writing:
query {
countries {
name
}
}
Press Cmd+Enter (or click the play button). You'll see a list of every country's name come back.
Add more fields:
query {
countries {
name
emoji
capital
continent {
name
}
}
}
Run again. The response now contains those fields. Notice the response shape mirrors the request shape exactly.
Variables. Below the query editor there's a "Query Variables" pane. Try:
query GetCountry($code: ID!) {
country(code: $code) {
name
emoji
capital
}
}
And in variables:
{ "code": "CA" }
Run.
GraphiQL is your design tool. When you're building a screen and trying to figure out what fields to ask for, you mock the query in GraphiQL first, refine it until the response is what you want, then copy it into a .graphql file in your iOS project. Get fluent with it before going further. It'll save you hours.
A modern alternative is Apollo Sandbox (sandbox.apollo.dev) — a more sophisticated explorer with the same job. Same idea, more bells and whistles. Either works.
Why introspection matters for iOS. Apollo's code generator uses introspection to fetch the schema. When you run
apollo-ios-cli fetch-schema, it runs this exact introspection query against the endpoint, gets back the full schema, and saves it toschema.graphqls. From that point on, code generation is purely local — the schema file is the source of truth.
You now understand GraphQL conceptually. Time to wire it into Swift.
Imagine doing GraphQL on iOS without any library. Conceptually it's straightforward — you saw in Chapter 5 that the wire protocol is just JSON over HTTP. So you'd:
URLRequest with POST https://api.example.com/graphql, body {"query": "...", "variables": {...}}.URLSession.Codable struct you wrote by hand.data field, ignore (or handle) errors.This works. People do it. But you immediately hit annoying friction:
Codable boilerplate, with extra steps.Apollo iOS exists to make this not your problem. It does three big things:
1. Code generation. You write .graphql files. Apollo's CLI reads the schema and your queries, validates them, and generates Swift types — one per operation, one per fragment. The generated types are exactly the shape your query asked for. You never write a Codable struct again for GraphQL data.
2. Normalized cache. Apollo automatically deduplicates objects in memory by __typename + id. Two queries that overlap on the same User share storage. Mutations update the cache; watchers on other queries see the new data instantly. (Chapter 20 has the full picture.)
3. Network plumbing. A pluggable interceptor chain (auth headers, retry, logging), HTTP transport, WebSocket transport for subscriptions, all integrated.
The cost is: you adopt their codegen pipeline and accept their API surface. For most iOS apps, this is unambiguously the right trade. For a tiny app with three queries, it's overkill — and a URLSession-plus-Codable approach might be simpler. Once you have a dozen queries, Apollo wins decisively.
We're targeting Apollo iOS 1.x. Version 1.0 was a major rewrite that introduced the modern type-safe code generator and cleaner APIs. Always check the latest 1.x release before pinning a version.
Let's actually set up a project. We'll create a fresh Xcode app, add Apollo, configure code generation, and run our first query against the Countries API.
Open Xcode. File → New → Project → iOS → App. Name it CountriesApp. Choose:
Save it somewhere. You'll have a basic ContentView.swift and CountriesAppApp.swift.
In Xcode: File → Add Package Dependencies... A dialog appears.
In the search bar, paste:
https://github.com/apollographql/apollo-ios
After a moment, the package resolves. On the right, set "Dependency Rule" to Up to Next Major Version with 1.0.0 (or pick the latest 1.x release). Click Add Package.
A second dialog asks which products to add. Apollo iOS is split into modules:
For this tutorial, check Apollo, ApolloWebSocket, and ApolloSQLite, all targeted at CountriesApp. Click Add Package.
Build the project (Cmd+B) to confirm it compiles. The Apollo dependency is now available.
The CLI is what reads your .graphql files and generates Swift code. Easiest install via Homebrew:
brew install apollographql/tap/apollo-ios-cli
Verify:
apollo-ios-cli --version
# Should print something like "Apollo iOS CLI 1.x.x"
If you don't use Homebrew, download a release binary from Apollo's GitHub releases and put it in your PATH. Some teams check the binary into the repo at Tools/apollo-ios-cli so CI doesn't depend on Homebrew.
Open Terminal and cd into your CountriesApp directory (the one containing CountriesApp.xcodeproj). Make a few directories:
mkdir -p CountriesApp/GraphQL
mkdir -p CountriesGraphQL
We'll keep .graphql operation files in CountriesApp/GraphQL/, and Apollo will generate Swift code into CountriesGraphQL/ as a separate Swift Package, which we'll then add to the app as a local SPM dependency.
Why a separate package? Because:
import once.From the project root (the CountriesApp directory containing the .xcodeproj):
apollo-ios-cli init --schema-namespace CountriesGraphQL --module-type swiftPackageManager
This creates a file apollo-codegen-config.json. Open it in any editor. It'll look something like this (with defaults):
{
"schemaNamespace": "CountriesGraphQL",
"input": {
"operationSearchPaths": ["**/*.graphql"],
"schemaSearchPaths": ["**/*.graphqls"]
},
"output": {
"testMocks": { "none": {} },
"schemaTypes": {
"path": "./CountriesGraphQL",
"moduleType": { "swiftPackageManager": {} }
},
"operations": { "inSchemaModule": {} }
}
}
Let's update it to be more deliberate. Replace its contents with:
{
"schemaNamespace": "CountriesGraphQL",
"input": {
"operationSearchPaths": ["CountriesApp/GraphQL/**/*.graphql"],
"schemaSearchPaths": ["CountriesApp/GraphQL/schema.graphqls"]
},
"output": {
"testMocks": { "none": {} },
"schemaTypes": {
"path": "./CountriesGraphQL",
"moduleType": { "swiftPackageManager": {} }
},
"operations": { "inSchemaModule": {} }
},
"options": {
"additionalInflectionRules": [],
"deprecatedEnumCases": "include",
"schemaDocumentation": "include",
"selectionSetInitializers": {
"operations": true,
"namedFragments": true,
"localCacheMutations": true
},
"warningsOnDeprecatedUsage": "include"
},
"schemaDownload": {
"downloadMethod": {
"introspection": {
"endpointURL": "https://countries.trevorblades.com/graphql",
"httpMethod": { "POST": {} },
"includeDeprecatedInputValues": false,
"outputFormat": "SDL"
}
},
"outputPath": "./CountriesApp/GraphQL/schema.graphqls"
}
}
Walking through the new pieces:
operationSearchPaths — where the CLI looks for your .graphql files. ** is a glob meaning "any directory."schemaSearchPaths — where to find the schema. We'll download it next.schemaTypes.path — where to write the generated SPM package.schemaTypes.moduleType.swiftPackageManager — emit a Package.swift so the output is its own SPM module. Alternatives are embeddedInTarget (drop generated files into your app target) and other (you handle it). SPM is the cleanest.operations.inSchemaModule — generated operation types live in the same module as the schema types. Simpler imports.options.selectionSetInitializers — emit memberwise initializers on response types. Critical for tests and optimistic updates. Always set to true.options.schemaDocumentation — pull docstrings from the schema into the generated Swift code. Great for autocompletion hints in Xcode.schemaDownload — tells the CLI how to fetch the schema. Introspection POST against the endpoint. Output as SDL (the .graphqls text format).Now we tell Apollo to download the schema:
apollo-ios-cli fetch-schema
You should see output like:
Loading Apollo iOS configuration...
Fetching schema...
Fetched schema for CountriesGraphQL.
Open CountriesApp/GraphQL/schema.graphqls. It's the full SDL of the Countries API — every type, every field, every doc comment. Skim it. You'll see types like Country, Continent, Language, plus the Query type listing top-level fields:
type Query {
continent(code: ID!): Continent
continents(filter: ContinentFilterInput): [Continent!]!
country(code: ID!): Country
countries(filter: CountryFilterInput): [Country!]!
language(code: ID!): Language
languages(filter: LanguageFilterInput): [Language!]!
}
This file is now the source of truth for code generation. Re-run apollo-ios-cli fetch-schema whenever the server schema changes. In a real project, you might commit this file to source control so everyone on the team has the same one.
Now write your first operation. Create the file CountriesApp/GraphQL/GetCountries.graphql with the contents:
query GetCountries {
countries {
code
name
emoji
capital
continent {
name
}
}
}
This is the same query we wrote in Chapter 6 — it asks for every country's basic info.
Now run codegen:
apollo-ios-cli generate
You should see output indicating successful generation. Check the directory tree:
CountriesApp/
├── CountriesApp.xcodeproj
├── CountriesApp/
│ └── GraphQL/
│ ├── GetCountries.graphql
│ └── schema.graphqls
├── CountriesGraphQL/ ← NEW
│ ├── Package.swift
│ └── Sources/
│ └── CountriesGraphQL/
│ ├── Schema/
│ │ ├── Objects/
│ │ │ ├── Country.graphql.swift
│ │ │ ├── Continent.graphql.swift
│ │ │ └── Query.graphql.swift
│ │ ├── ...
│ └── Operations/
│ └── Queries/
│ └── GetCountriesQuery.graphql.swift
└── apollo-codegen-config.json
Apollo just emitted a complete Swift Package. Let's read what it generated.
In Xcode: File → Add Package Dependencies → Add Local... Choose the CountriesGraphQL directory. Click Add Package. In the next dialog, check CountriesGraphQL and target CountriesApp. Click Add Package.
Build the project. It compiles. You can now import CountriesGraphQL from anywhere in your app code.
Open CountriesGraphQL/Sources/CountriesGraphQL/Operations/Queries/GetCountriesQuery.graphql.swift. You'll see something like:
@_exported import ApolloAPI
public extension CountriesGraphQL {
class GetCountriesQuery: GraphQLQuery {
public static let operationName: String = "GetCountries"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query GetCountries { countries { __typename code name emoji capital continent { __typename name } } }"#
))
public init() {}
public struct Data: CountriesGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType {
CountriesGraphQL.Objects.Query
}
public static var __selections: [ApolloAPI.Selection] = [
.field("countries", [Country].self, arguments: ["filter": .null]),
]
public var countries: [Country] { __data["countries"] }
// ... nested struct Country with the same pattern ...
}
}
}
Don't be intimidated by the boilerplate — most of it is machinery for Apollo's runtime. The important parts are:
GetCountriesQuery is a class. You'll instantiate it with GetCountriesQuery() (no args, since the query has no variables).GetCountriesQuery.Data is a struct. It's the response shape.Data, countries: [Country] is a property. The nested Country struct has code, name, emoji, capital, and continent as properties — exactly the fields we asked for.The crucial detail: the nested Country is GetCountriesQuery.Data.Country — not a top-level reusable Country type. It's the slice of Country that this specific query selected. If you write a different query asking for different fields (say, currency), you'll get a different GetCountriesQuery2.Data.Country with currency instead of, say, emoji.
This deliberate non-reuse is the core of Apollo's type safety: you can never read a field your query didn't ask for, because the type literally doesn't have that property. Compile-time guarantee. Compare with hand-rolled Codable where you write a "fat" struct with all possible fields and pray that the server included them.
Notice this string inside the generated code:
"query GetCountries { countries { __typename code name emoji capital continent { __typename name } } }"
That's the literal query string that gets sent over the wire. Apollo automatically inserts __typename everywhere — you didn't ask for it, but Apollo needs it for cache normalization (Chapter 20). It's a free hidden field that costs nothing.
Anytime you:
.graphql file.graphql file…you must re-run apollo-ios-cli generate. Otherwise your generated code is stale.
For automation, you can add a "Run Script" build phase to your app target:
cd "$SRCROOT" && /opt/homebrew/bin/apollo-ios-cli generate
Place it before "Compile Sources." Now every build regenerates. Some teams skip this and run codegen manually + commit generated code to git (so CI builds don't depend on the CLI). Either approach is valid.
Tip. When working with a backend that's actively changing the schema, re-fetch the schema first thing in the morning. A stale schema + a server change means your old queries silently 400 against the new server. Code generation will catch most schema-vs-query mismatches at build time.
Now we put it all together. This part walks through creating a real, working screen that lists countries.
Create a new Swift file in your app target: CountriesApp/Network/ApolloNetwork.swift. Here's the basic setup:
import Apollo
import CountriesGraphQL
import Foundation
enum Network {
static let apollo: ApolloClient = {
let url = URL(string: "https://countries.trevorblades.com/graphql")!
let store = ApolloStore()
let urlSessionClient = URLSessionClient()
let interceptorProvider = DefaultInterceptorProvider(
client: urlSessionClient,
shouldInvalidateClientOnDeinit: true,
store: store
)
let transport = RequestChainNetworkTransport(
interceptorProvider: interceptorProvider,
endpointURL: url
)
return ApolloClient(networkTransport: transport, store: store)
}()
}
Read it line by line.
url — the GraphQL endpoint. One URL serves all your queries, mutations, and (over WebSocket) subscriptions.
store = ApolloStore() — the cache. By default, in-memory only. Apollo's normalized cache lives behind this object. Every fetched response is written here; every observed query reads from here.
urlSessionClient = URLSessionClient() — Apollo's URLSession wrapper. It does the actual HTTP. You can configure it with custom session config (timeouts, proxy, etc.) but the default is fine.
interceptorProvider — interceptors are middleware that run on every request. The DefaultInterceptorProvider ships with a sensible default chain: parse incoming headers, write to cache, request retries on certain failures, etc. You can subclass this to inject your own (auth, logging, custom retry) — Chapter 26.
transport — orchestrates each request through the interceptor chain.
ApolloClient(networkTransport:, store:) — the public API. You'll call .fetch(...), .perform(...), .subscribe(...), .watch(...) on this.
Wrapped in an enum Network namespace so we have a single static accessor: Network.apollo. For non-trivial apps, you'd inject this via a repository or environment value (Chapter 34) instead of using a global.
We already wrote GetCountries.graphql in Chapter 10 and generated the Swift type. Now let's actually run it.
Apollo's primitive fetch API is callback-based:
Network.apollo.fetch(query: GetCountriesQuery()) { result in
switch result {
case .success(let response):
if let countries = response.data?.countries {
print("Got \(countries.count) countries")
for country in countries.prefix(5) {
print("- \(country.emoji) \(country.name)")
}
}
if let errors = response.errors {
print("Errors:", errors)
}
case .failure(let error):
print("Network error:", error)
}
}
What this is doing:
GetCountriesQuery() — instantiate the generated query. No variables, so no init args.fetch(query:) — kicks off the request. The completion handler runs when done (on a background queue, by default).result: Result<GraphQLResult<Data>, Error> — the Result wraps either a successful GraphQL response or a transport error.response.data?.countries — the typed response. data is optional because if the server returned only errors and no data, data is nil.response.errors — GraphQL-level errors (Chapter 29). Can coexist with data.Run this from ContentView.swift (onAppear { ... }) and check the Xcode console. You should see country names print.
The callback API is fine but feels archaic in 2026. Apollo iOS doesn't ship a built-in async wrapper, so we'll write a small one. Create CountriesApp/Network/ApolloClient+Async.swift:
import Apollo
import ApolloAPI
extension ApolloClient {
func fetchAsync<Query: GraphQLQuery>(
query: Query,
cachePolicy: CachePolicy = .returnCacheDataElseFetch
) async throws -> Query.Data {
try await withCheckedThrowingContinuation { continuation in
fetch(query: query, cachePolicy: cachePolicy) { result in
switch result {
case .success(let response):
if let data = response.data {
continuation.resume(returning: data)
} else if let firstError = response.errors?.first {
continuation.resume(throwing: firstError)
} else {
continuation.resume(throwing: GraphQLClientError.noData)
}
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
func performAsync<Mutation: GraphQLMutation>(
mutation: Mutation
) async throws -> Mutation.Data {
try await withCheckedThrowingContinuation { continuation in
perform(mutation: mutation) { result in
switch result {
case .success(let response):
if let data = response.data {
continuation.resume(returning: data)
} else if let firstError = response.errors?.first {
continuation.resume(throwing: firstError)
} else {
continuation.resume(throwing: GraphQLClientError.noData)
}
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
enum GraphQLClientError: Error {
case noData
}
GraphQLError from Apollo conforms to Error, so we can throw it directly. The firstError approach is simplistic — for partial-success scenarios you'd want to surface both data and errors. We'll refine this in Chapter 29.
Create CountriesApp/Features/Countries/CountriesViewModel.swift:
import CountriesGraphQL
import Observation
@Observable
final class CountriesViewModel {
enum State {
case idle
case loading
case loaded([GetCountriesQuery.Data.Country])
case failed(Error)
}
var state: State = .idle
@MainActor
func load() async {
state = .loading
do {
let data = try await Network.apollo.fetchAsync(query: GetCountriesQuery())
state = .loaded(data.countries)
} catch {
state = .failed(error)
}
}
}
A few notes:
@Observable (iOS 17+) makes property changes automatically tracked by SwiftUI views that read them.state is a four-state enum modeling the typical async UI lifecycle.@MainActor on load() means the function and any state mutations happen on the main thread — needed because we're updating Observable state that the UI reads.Replace ContentView.swift:
import CountriesGraphQL
import SwiftUI
struct ContentView: View {
@State private var vm = CountriesViewModel()
var body: some View {
NavigationStack {
content
.navigationTitle("Countries")
}
.task { await vm.load() }
}
@ViewBuilder
private var content: some View {
switch vm.state {
case .idle, .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .loaded(let countries):
List(countries, id: \.code) { country in
HStack {
Text(country.emoji ?? "🏳️")
.font(.largeTitle)
VStack(alignment: .leading) {
Text(country.name)
.font(.headline)
Text("Capital: \(country.capital ?? "—")")
.font(.caption)
.foregroundStyle(.secondary)
Text(country.continent.name)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
case .failed(let error):
ContentUnavailableView(
"Couldn't load countries",
systemImage: "exclamationmark.triangle",
description: Text(error.localizedDescription)
)
}
}
}
Build and run. You'll see a list of every country with its flag, name, capital, and continent. Congratulations — you just built a working GraphQL-powered iOS screen.
A few things to notice in the view code:
countries and access country.code, country.name, etc. directly — typed properties. No Codable indirection, no string keys.country.emoji is String? (nullable in the schema → optional in Swift). country.continent.name is String (non-null in the schema). Nullability flows from the schema all the way to the UI.country.continent is a nested struct (GetCountriesQuery.Data.Country.Continent). It has just name because that's all our query asked for.The countries list query had no variables. Most real queries do. Let's add a "country detail" screen that uses one.
Create CountriesApp/GraphQL/GetCountryDetails.graphql:
query GetCountryDetails($code: ID!) {
country(code: $code) {
code
name
capital
emoji
currency
phone
languages {
code
name
native
}
continent {
code
name
}
states {
code
name
}
}
}
Run apollo-ios-cli generate. A new GetCountryDetailsQuery type appears in the generated module.
Open GetCountryDetailsQuery.graphql.swift (in CountriesGraphQL/Sources/CountriesGraphQL/Operations/Queries/). You'll see something like:
public init(code: ID) {
self.code = code
}
public var code: ID
public var __variables: Variables? { ["code": code] }
Apollo turned $code: ID! into init(code: ID). You instantiate the query like:
let query = GetCountryDetailsQuery(code: "CA")
The variable code of type ID! became a non-optional ID (which is a typealias for String). If it had been ID (nullable in the schema), it would become GraphQLNullable<ID> — covered in Chapter 15.
Add to your view model:
extension CountriesViewModel {
@MainActor
func loadDetails(code: String) async throws -> GetCountryDetailsQuery.Data.Country? {
let data = try await Network.apollo.fetchAsync(query: GetCountryDetailsQuery(code: ID(code)))
return data.country
}
}
ID(code) constructs an ID value from a String. The ID type is a struct wrapping a string and is just there for type discipline.
The country in the response is Country? because the schema declares country(code: ID!): Country (note: no ! after Country) — meaning if the code doesn't match any country, the server returns null.
A common newcomer impulse is:
// DON'T DO THIS
let queryString = "query { country(code: \"\(userInput)\") { name } }"
Reasons not to:
Variables exist precisely to keep the query a static, hashable artifact and the values a separate concern. Always use variables.
Imagine your app has two screens that show country info: a list (basic info) and a detail screen (full info). They overlap on fields like code, name, emoji, continent.name. Without fragments, you'd write the same selection set twice.
A fragment is a named, reusable selection set on a specific type. It lets you DRY up overlapping queries.
Create CountriesApp/GraphQL/Fragments/CountrySummary.graphql:
fragment CountrySummary on Country {
code
name
emoji
capital
continent {
name
}
}
Read it: "a fragment named CountrySummary, valid on the Country type, selecting these fields."
Update your existing queries to spread the fragment using ...:
# GetCountries.graphql
query GetCountries {
countries {
...CountrySummary
}
}
# GetCountryDetails.graphql
query GetCountryDetails($code: ID!) {
country(code: $code) {
...CountrySummary
currency
phone
languages {
code
name
native
}
states {
code
name
}
}
}
The ...CountrySummary syntax is "spread the fragment here." On the wire, the server fully expands fragments before executing — they're a client-side organization tool, not a server feature.
Run apollo-ios-cli generate. Two things change:
CountrySummary appears (the fragment's generated representation).fragments.countrySummary accessors:country.fragments.countrySummary.name // String
country.fragments.countrySummary.continent.name // String
You can still access fields directly — country.name still works, since the fragment's fields are flattened into the country struct. The fragments.countrySummary form gives you the fragment as a value, which is what enables sharing.
Now write one cell that takes the fragment:
import CountriesGraphQL
import SwiftUI
struct CountryCell: View {
let country: CountrySummary
var body: some View {
HStack {
Text(country.emoji)
.font(.largeTitle)
VStack(alignment: .leading) {
Text(country.name)
.font(.headline)
Text(country.continent.name)
.font(.caption)
.foregroundStyle(.secondary)
if let capital = country.capital {
Text("Capital: \(capital)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
}
}
And use it from both the list and the detail screen header:
// In the list
List(countries, id: \.code) { country in
CountryCell(country: country.fragments.countrySummary)
}
// In the detail screen header
CountryCell(country: detail.country.fragments.countrySummary)
One cell, two screens, perfectly shared types. If you change the fragment (say, drop capital), both queries update simultaneously and the cell's compile errors point you at exactly what to fix.
A different fragment use case: discriminating types when a field returns an interface or union. Suppose a schema has:
union SearchResult = User | Post | Comment
type Query {
search(term: String!): [SearchResult!]!
}
You can't just ask for fields on SearchResult because the available fields depend on the concrete type. You use inline fragments to discriminate:
query Search($term: String!) {
search(term: $term) {
__typename
... on User { id, name }
... on Post { id, title }
... on Comment { id, body }
}
}
... on User { ... } reads as "if this thing is a User, also select these fields." Apollo's generated code maps each case to a Swift enum case so you switch on it:
for result in data.search {
switch result {
case .user(let user):
print("User:", user.name)
case .post(let post):
print("Post:", post.title)
case .comment(let comment):
print("Comment:", comment.body)
case .none:
break // unknown future variant — forward-compatible
}
}
The .none case is for forward compatibility: if the server adds a new variant to the union your client doesn't recognize, you don't crash, you just hit .none. This is GraphQL's elegant approach to versioning.
One more reason fragments matter: they're the unit Apollo's normalized cache reasons about. Two queries that both spread CountrySummary over a Country object end up with the same cache entry. Update that country (via a mutation, say), and both screens' watched data refresh automatically. We'll see this play out in Chapters 20 and 22.
You've seen String! vs String and String vs String? in passing. Let's nail the model, because this is where iOS devs trip up.
Combining the schema's nullability with Apollo's code generation:
| Schema | Swift property type | Notes |
|---|---|---|
name: String! |
String |
Server guarantees non-null. |
name: String |
String? |
Server may return null. |
tags: [Tag!]! |
[Tag] |
Always an array, never-null elements. |
tags: [Tag!] |
[Tag]? |
Array may be null. |
tags: [Tag]! |
[Tag?] |
Array always present, elements may be null. |
tags: [Tag] |
[Tag?]? |
Both may be null. |
These are direct mechanical translations. No surprises.
GraphQLNullable<T>The interesting optionality is on variables. GraphQL has three states for an input variable:
{"code": "CA"}.null explicitly. {"code": null}.{} (no code key at all).For a non-null input variable ($code: ID!), only state 1 is valid. The schema forbids the others.
For a nullable input variable ($code: ID), all three states are valid — and they may mean different things to the server. Consider an "update" mutation:
mutation UpdateUser($id: ID!, $name: String, $bio: String) {
updateUser(id: $id, name: $name, bio: $bio) { id }
}
A reasonable server semantics might be: "if a field is omitted, leave it unchanged. If a field is null, clear it." So:
{"name": "Ada"} → name becomes "Ada"{"name": null} → name is cleared{} → name is left aloneYou need to be able to express all three. Swift's String? only has two states (some or nil), so it can't directly model this. Apollo's solution is GraphQLNullable<T>, an enum with three cases:
enum GraphQLNullable<Wrapped> {
case some(Wrapped) // explicit value
case null // explicit null
case none // omitted (variable not sent)
}
When a variable is nullable, the generated initializer takes GraphQLNullable<T> instead of T?:
// $name: String → name: GraphQLNullable<String>
let mutation = UpdateUserMutation(
id: "42",
name: .some("Ada"), // pass a value
bio: .null // pass null explicitly
)
// or omit:
let mutation = UpdateUserMutation(
id: "42",
name: .some("Ada"),
bio: .none // don't include bio in variables
)
You'll mostly construct .some(value) for "I have a value." Use .null when you specifically want to clear or null something. Use .none when you want to omit.
OptionalYou usually have data already in Swift Optional form — say, an bio: String? from your view model. To convert, an extension is handy:
extension GraphQLNullable {
init(optional: Wrapped?) {
self = optional.map { .some($0) } ?? .none // nil → .none
}
}
// Usage
let bio: String? = userPreference
let mutation = UpdateUserMutation(id: "42", name: .none, bio: GraphQLNullable(optional: bio))
That treats nil as "omit." If your semantics are "nil means clear," use .null instead:
extension GraphQLNullable {
init(optionalAsNull: Wrapped?) {
self = optionalAsNull.map { .some($0) } ?? .null
}
}
Pick whichever convention matches your backend's behavior and apply it consistently.
Gotcha. This is the single most confusing thing for new Apollo iOS users. Spend ten minutes drilling the three cases until they're automatic. Once they are, the rest of Apollo's API is straightforward.
You can read data. Now let's change it.
A mutation is a write operation. Syntactically, it's just like a query but starts with mutation:
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
body
publishedAt
author {
id
name
}
}
}
A few things worth dwelling on:
Same selection set rules. You select fields on the returned object the same way as queries. The fields you select after createPost(...) are the fields that come back in the response.
Why ask for fields back? Because the server is going to give you the freshly created object. Asking for id, title, etc. means you don't need a follow-up query. Just as importantly, those returned fields go into Apollo's cache, automatically updating any watcher that's looking at this object. We'll exploit this in Chapter 25.
Serial execution. If you put multiple top-level fields in a mutation document, the server runs them in order:
mutation Cleanup {
deleteOldDrafts(beforeDate: "2024-01-01") { count }
archiveStaleProjects { count }
}
deleteOldDrafts runs to completion first, then archiveStaleProjects. Compare with queries, where the spec allows parallel execution.
The input convention. By GraphQL community convention (not spec), mutations take a single argument named input, of an input type, rather than many positional arguments. So:
# Convention
mutation CreatePost($input: CreatePostInput!) { ... }
# Not conventional
mutation CreatePost($title: String!, $body: String!, $tags: [String!]) { ... }
The reason: input types evolve cleanly. Adding a field to CreatePostInput doesn't change the mutation's signature on the wire. Adding a positional argument does.
The Countries API is read-only, so we'll switch to a hypothetical posts API for examples in this part.
Imagine a posts schema:
input CreatePostInput {
title: String!
body: String!
tags: [String!]
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}
Your operation:
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
body
publishedAt
author {
id
name
}
}
}
After apollo-ios-cli generate, you get:
CreatePostMutation — class.CreatePostMutation.Data.CreatePost — the response struct.CreatePostInput — Swift struct mirroring the input type.func createPost(title: String, body: String, tags: [String]?) async throws -> CreatePostMutation.Data.CreatePost {
let input = CreatePostInput(
title: title,
body: body,
tags: GraphQLNullable(optional: tags)
)
let data = try await Network.apollo.performAsync(mutation: CreatePostMutation(input: input))
return data.createPost
}
Note perform (not fetch) — perform is the API for mutations. It bypasses the cache for the request itself (mutations always hit the network) but writes the response into the cache.
Use it from a view:
struct CreatePostView: View {
@State private var title = ""
@State private var body = ""
@State private var isCreating = false
var body: some View {
Form {
TextField("Title", text: $title)
TextEditor(text: $body)
Button("Create") {
Task {
isCreating = true
defer { isCreating = false }
do {
let post = try await createPost(title: title, body: body, tags: nil)
print("Created post:", post.id)
} catch {
print("Failed:", error)
}
}
}
.disabled(title.isEmpty || isCreating)
}
}
}
That's the basic flow. The real questions are: how does this affect the rest of my UI? — which we'll answer in the next chapter.
The mutation runs, the response comes back, the cache writes the new Post. Now: a feed screen elsewhere in your app is showing a list of posts. Does it update?
Not automatically. Apollo can't know your new post belongs at the top of the feed (or anywhere in particular). You have to tell it. Three strategies, in order of increasing sophistication:
Easiest, dumbest, often correct:
let post = try await createPost(title: title, body: body, tags: nil)
// Refetch the feed
_ = try await Network.apollo.fetchAsync(
query: FeedQuery(),
cachePolicy: .fetchIgnoringCacheData
)
.fetchIgnoringCacheData forces a network fetch and updates the cache. Watchers on FeedQuery (Chapter 22) get the new data automatically.
Cost: an extra round trip. Acceptable for low-traffic screens or small feeds. Wasteful for huge lists.
For surgical updates, you read the existing cached query data, mutate it, and write it back. Apollo handles this via client.store.withinReadWriteTransaction:
try await Network.apollo.store.withinReadWriteTransaction { transaction in
try transaction.update(query: FeedQuery()) { data in
// data.feed is the cached feed array. Insert the new post at the top.
// (Construction of the new feed entry depends on your generated types.)
let newEntry = FeedQuery.Data.Feed(/* fields... */)
data.feed.insert(newEntry, at: 0)
}
}
Apollo notifies all watchers on FeedQuery that data changed; UIs update.
Caveats:
update call throws if there's no cached data for that query. If the user hasn't visited the feed yet, you'll hit JSONDecodingError.missingValue. Either ensure the query has been observed before mutating, or catch and ignore that specific error.selectionSetInitializers.operations: true (we set this in Chapter 8).The classy version: update the cache immediately, before the server responds, with a guess at the result. Render UI from that. When the server responds, reconcile. Covered in detail in Chapter 25.
For a "like" button, optimistic updates are essential — users tap and expect instant feedback. For a "create post" flow, where the user's already going to navigate away to a confirmation screen, a refetch or manual update is fine.
This is the part most tutorials gloss over. Misunderstanding the cache is the #1 source of GraphQL bugs in production iOS apps. Take your time here.
In REST, each endpoint is a separate URL. URLSession's default behavior, with URLCache, is HTTP caching: same URL, same response (with Cache-Control and ETag headers respected), served from disk. It works automatically. It's keyed by URL.
GraphQL has one URL — https://api.example.com/graphql. Every query goes through it. HTTP caching is useless: same URL means the cache treats every request as a hit on the same resource. You'd need to key by the request body (the query + variables), which URLCache doesn't do natively.
So GraphQL clients implement caching at a higher level. Apollo's choice — and it's a brilliant one — is normalized caching: store objects by their identity, not by request path.
Let's walk through the idea concretely.
If the cache were just (query string, variables) → response JSON, you'd hit a problem. Suppose:
query A { me { id name email } }
query B { feed { id title author { id name } } }
Both queries return some User data. With a naive per-query cache:
me: { id: "1", name: "Ada", email: "ada@x.com" }.feed[].author: { id: "1", name: "Ada" } (no email).Now a mutation updates the user's name. We need both screens to update. With a naive cache, each entry is independent — we'd have to know to invalidate both.
Apollo instead stores objects in a flat, key-value structure. Every object — wherever it appears — gets its own entry, keyed by __typename + id. References between objects are stored as keys, not nested copies.
After running queries A and B above, the cache looks like:
User:1 → { id: "1", name: "Ada", email: "ada@x.com" }
Post:42 → { id: "42", title: "On Engines", author: → User:1 }
Post:43 → { id: "43", title: "Programs", author: → User:1 }
ROOT_QUERY → {
me → → User:1,
feed → [→ Post:42, → Post:43]
}
The → is a reference (a string like "User:1"), not a copy. The user data lives in one place.
When a mutation comes back with { "id": "1", "name": "Ada Lovelace", ... }, Apollo writes those fields into User:1. Both queries' watchers see the change because they both ultimately read from User:1.
This is the same pattern as Core Data with object identity, or Redux with normalized state. It works for the same reasons: a single source of truth per entity.
By default, the key is <__typename>:<id>. So:
User with id: "1" → key User:1Country with id: "CA" → key Country:CAFor this to work, Apollo needs:
__typename. Apollo automatically inserts __typename into every selection set it generates — that's why you see it in the operation document strings.id (or whatever your cache key field is). This is on you. Forget to select id and the object can't be cached normally — it gets stored as a per-query entry with no cross-query sharing.If your schema uses something other than id (like code for countries, since the Countries API uses ISO codes as keys), you configure a custom resolver. Create CountriesApp/Network/CacheKeyConfiguration.swift:
import ApolloAPI
import CountriesGraphQL
extension CountriesGraphQL.SchemaMetadata {
public static func cacheKeyInfo(for type: ApolloAPI.Object, object: ApolloAPI.ObjectData) -> ApolloAPI.CacheKeyInfo? {
switch type {
case CountriesGraphQL.Objects.Country, CountriesGraphQL.Objects.Continent, CountriesGraphQL.Objects.Language:
return try? CacheKeyInfo(jsonValue: object["code"])
default:
return nil // fall back to default (id)
}
}
}
Now the cache uses code as the key for Country/Continent/Language. Without this, Apollo doesn't know how to identify these objects and falls back to per-query caching.
Practical rule. Always include
id(or your equivalent) in every selection set on a typed object. Even if you don't use the field in the UI. The cache needs it.
What if Query A asks for User { id name email } and Query B asks for User { id name avatarURL }? Both write to User:1. The cache merges:
User:1 → { id: "1", name: "Ada", email: "ada@x.com", avatarURL: "https://..." }
If a third query later asks for User { id name email }, Apollo can serve the response from the cache without hitting the network, because all three fields (id, name, email) are present. If it asks for something the cache doesn't have (User { id phoneNumber }), Apollo goes to the network.
This per-field cache hit logic is the engine behind Apollo's "smart" cache policies. The cache isn't all-or-nothing; it knows which fields it has and which it doesn't.
Every fetch call takes a CachePolicy argument that decides how the cache and network interact:
| Policy | Behavior |
|---|---|
.returnCacheDataElseFetch (default) |
Try cache. If it has all requested fields, return them. Otherwise, network. |
.fetchIgnoringCacheData |
Always network. Update cache with result. |
.fetchIgnoringCacheCompletely |
Always network. Don't even update the cache. |
.returnCacheDataDontFetch |
Cache only. Returns nil if cache miss. Never goes to network. |
.returnCacheDataAndFetch |
Return cache immediately, then fetch and update. The completion handler fires twice. |
Use cases:
.returnCacheDataElseFetch — fine for "load once, show forever" data like a list of countries..fetchIgnoringCacheData — pull-to-refresh. User wants the latest, period..fetchIgnoringCacheCompletely — debugging, or background prefetches you don't want polluting cache..returnCacheDataDontFetch — for offline-first reads, or rendering UI from prefetched data without a network round trip..returnCacheDataAndFetch — most app screens. Show cached data instantly for fast paint, then update from the network. Best UX. Note: the fetch callback fires twice with this policy.Most async wrappers (including the simple one in Chapter 12) only handle the first callback. To handle both, use client.watch instead of fetch. That's the next chapter.
fetch is a one-shot. watch is a subscription to cache changes. You get a callback every time the underlying data changes — initial fetch, network refresh, mutation that touched the data, manual cache write, anything.
let watcher = Network.apollo.watch(
query: GetCountriesQuery(),
cachePolicy: .returnCacheDataAndFetch
) { result in
switch result {
case .success(let response):
if let countries = response.data?.countries {
// Update UI with countries
}
case .failure(let error):
print("watch error:", error)
}
}
// Later, when done:
watcher.cancel()
The watcher object is a GraphQLQueryWatcher. Hold onto it. Cancel it when done (typically when the view goes away). Forgetting to cancel is the #1 cause of memory leaks in Apollo iOS apps.
.returnCacheDataAndFetch with watch gives you the ideal app-screen experience: cache renders instantly, network updates the cache, the watcher fires again with fresh data. From the UI's perspective, it just keeps updating with the latest available info.
We'll wrap this in a SwiftUI-friendly observer in Chapter 33 so cancellation is automatic.
The default ApolloStore() is in-memory only. Cache is wiped on app relaunch. For most apps you want cache that survives — fast cold starts, offline browse, less network.
import Apollo
import ApolloSQLite
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let cacheURL = documentsURL.appendingPathComponent("apollo_cache.sqlite")
let cache = try SQLiteNormalizedCache(fileURL: cacheURL)
let store = ApolloStore(cache: cache)
Pass store to ApolloClient the same way as before. Now your normalized cache is backed by SQLite. Reads and writes go through it transparently.
Implications:
To wipe:
Network.apollo.clearCache { _ in }
That nukes the cache contents (and clears the SQLite file). Always do this on logout.
Beyond fetch/watch, you can read and write the cache directly. Useful for:
Network.apollo.store.withinReadTransaction { transaction in
let data = try transaction.read(query: GetCountriesQuery())
print("Cached countries:", data.countries.count)
} completion: { result in
if case .failure(let error) = result {
print("Read error:", error)
}
}
read throws if the cache doesn't have all the requested fields.
Network.apollo.store.withinReadWriteTransaction { transaction in
try transaction.write(
data: GetCountriesQuery.Data(countries: customCountries),
for: GetCountriesQuery()
)
} completion: { result in
// ...
}
Construct the Data value yourself (using the memberwise init) and write it into the cache against a specific query.
The pattern from Chapter 18, written more carefully:
let createdPost = try await Network.apollo.performAsync(mutation: CreatePostMutation(input: input))
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
Network.apollo.store.withinReadWriteTransaction({ transaction in
try transaction.update(query: FeedQuery()) { data in
// Mutate the cached feed list in place
let newEntry = FeedQuery.Data.Feed(/* construct from createdPost */)
data.feed.insert(newEntry, at: 0)
}
}, completion: { result in
cont.resume(with: result)
})
}
update is sugar around read + write. Apollo notifies watchers; lists update across the app.
For day-to-day debugging, install a logging interceptor (Chapter 26) and watch the console. For deeper inspection, you can dump the SQLite file (use any SQLite browser) or read raw cache keys via store.withinReadTransaction { transaction in ... }.
Apollo Studio (a paid product) has a more sophisticated dev-time cache inspector. For most apps, console logging is enough.
Apply the change locally before the server responds. The UI feels instant. The server's eventual response either confirms or corrects the local state.
For a "like" mutation:
mutation LikePost($id: ID!) {
likePost(id: $id) {
id
likeCount
isLikedByMe
}
}
func likePost(_ post: Post) {
Network.apollo.perform(
mutation: LikePostMutation(id: post.id),
publishResultToStore: true,
optimisticData: LikePostMutation.Data(
likePost: .init(
id: post.id,
likeCount: post.likeCount + 1,
isLikedByMe: true
)
)
) { result in
// Real response handling
}
}
What happens:
For this to work, the optimistic data must be a fully-formed Data value of the mutation's response type. That's why you need selectionSetInitializers.operations: true in the codegen config (Chapter 8).
If the network errors out, Apollo restores the pre-optimistic state automatically. The user sees the like count snap back. You should also surface an error toast or revert the user's tap state.
Optimistic updates are the single biggest UX upgrade you can extract from Apollo's normalized cache. Once you start using them, plain mutations feel sluggish.
The previous parts give you working code. The following parts are what you need to ship.
Real apps need auth. Apollo's mechanism for "stuff that runs on every request" is the interceptor chain — middleware that runs in order, each interceptor having a chance to modify the request, the response, or short-circuit the chain.
Add Authorization: Bearer <token> to every outgoing request:
import Apollo
import ApolloAPI
import Foundation
final class AuthorizationInterceptor: ApolloInterceptor {
var id: String = UUID().uuidString
private let tokenProvider: () async -> String?
init(tokenProvider: @escaping () async -> String?) {
self.tokenProvider = tokenProvider
}
func interceptAsync<Operation>(
chain: any RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?,
completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
) {
Task {
if let token = await tokenProvider() {
request.addHeader(name: "Authorization", value: "Bearer \(token)")
}
chain.proceedAsync(
request: request,
response: response,
interceptor: self,
completion: completion
)
}
}
}
Interceptors implement interceptAsync(chain:request:response:completion:). Each one either modifies the request/response and calls chain.proceedAsync(...) to continue, or short-circuits with chain.handleErrorAsync(...) or by calling completion directly.
To wire this into the client, subclass DefaultInterceptorProvider:
final class AppInterceptorProvider: DefaultInterceptorProvider {
private let tokenProvider: () async -> String?
init(client: URLSessionClient, store: ApolloStore, tokenProvider: @escaping () async -> String?) {
self.tokenProvider = tokenProvider
super.init(client: client, store: store)
}
override func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [any ApolloInterceptor] {
var chain = super.interceptors(for: operation)
chain.insert(AuthorizationInterceptor(tokenProvider: tokenProvider), at: 0)
return chain
}
}
Use it:
let provider = AppInterceptorProvider(
client: URLSessionClient(),
store: store,
tokenProvider: { await TokenStore.shared.currentAccessToken() }
)
The token provider is async because typical token stores are actor-isolated. Always grab a fresh token at request time, not at provider construction time — tokens expire.
A more sophisticated interceptor: catch a 401 response, refresh the access token, retry the original request:
final class TokenRefreshInterceptor: ApolloInterceptor {
var id: String = UUID().uuidString
private let refresh: () async throws -> Void
init(refresh: @escaping () async throws -> Void) {
self.refresh = refresh
}
func interceptAsync<Operation>(
chain: any RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?,
completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
) {
guard let response, response.httpResponse.statusCode == 401 else {
chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion)
return
}
Task {
do {
try await refresh()
chain.retry(request: request, completion: completion)
} catch {
chain.handleErrorAsync(error, request: request, response: response, completion: completion)
}
}
}
}
chain.retry(request:completion:) re-runs the request through the chain — picking up the new token from the auth interceptor. Place this interceptor after parsing in the chain, so it sees the parsed status code.
A subtle production detail: if many requests fire simultaneously and all hit 401, you'll trigger N concurrent refreshes. Serialize the refresh through an actor or a single Task to avoid storms.
For development, log every operation:
final class LoggingInterceptor: ApolloInterceptor {
var id: String = UUID().uuidString
func interceptAsync<Operation>(
chain: any RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?,
completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
) {
print("→ \(Operation.operationName)", request.operationIdentifier ?? "")
let start = Date()
chain.proceedAsync(request: request, response: response, interceptor: self) { result in
let elapsed = Date().timeIntervalSince(start)
switch result {
case .success(let r):
let source = r.source.description
print("← \(Operation.operationName) [\(source)] \(Int(elapsed * 1000))ms")
case .failure(let error):
print("✗ \(Operation.operationName) \(error.localizedDescription)")
}
completion(result)
}
}
}
Insert it at the top of the chain only in DEBUG builds:
override func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [any ApolloInterceptor] {
var chain = super.interceptors(for: operation)
chain.insert(AuthorizationInterceptor(tokenProvider: tokenProvider), at: 0)
#if DEBUG
chain.insert(LoggingInterceptor(), at: 0)
#endif
return chain
}
response.source tells you whether data came from .cache or .server — invaluable for debugging cache behavior.
func logout() async {
await TokenStore.shared.clear()
Network.apollo.clearCache { _ in }
}
Always clear both the token and the cache. Otherwise the next user signing in on the same device sees stale data from the previous account.
Two common pagination flavors: cursor-based (modern, Relay-style) and offset-based (traditional, simpler).
The schema uses connection types:
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Query {
feed(first: Int!, after: String): PostConnection!
}
Why this shape? Because cursors are stable under inserts. If you're paginating by offset and someone inserts a new item at position 5 between two of your fetches, you'd see item at offset 10 twice (once at offset 10 in fetch 1, again at offset 11 in fetch 2). Cursors avoid this — each cursor is an opaque position pointer that the server resolves correctly even as the underlying data shifts.
The query:
query Feed($first: Int!, $after: String) {
feed(first: $first, after: $after) {
edges {
node {
id
title
author { id name }
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
A view model managing pagination:
@Observable
final class FeedViewModel {
var posts: [FeedQuery.Data.Feed.Edge.Node] = []
var isLoadingMore = false
var hasError: Error?
private var endCursor: String?
private var hasNextPage = true
@MainActor
func loadInitial() async {
posts = []
endCursor = nil
hasNextPage = true
await loadMore()
}
@MainActor
func loadMore() async {
guard !isLoadingMore, hasNextPage else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let data = try await Network.apollo.fetchAsync(
query: FeedQuery(
first: 20,
after: GraphQLNullable(optional: endCursor)
)
)
posts.append(contentsOf: data.feed.edges.map(\.node))
endCursor = data.feed.pageInfo.endCursor
hasNextPage = data.feed.pageInfo.hasNextPage
} catch {
hasError = error
}
}
}
The view triggers loadMore when the last cell appears:
struct FeedView: View {
@State private var vm = FeedViewModel()
var body: some View {
List {
ForEach(vm.posts, id: \.id) { post in
PostCell(post: post)
.onAppear {
if post.id == vm.posts.last?.id {
Task { await vm.loadMore() }
}
}
}
if vm.isLoadingMore {
ProgressView().frame(maxWidth: .infinity)
}
}
.task { await vm.loadInitial() }
}
}
Look closely: FeedQuery(first: 20, after: nil) and FeedQuery(first: 20, after: "abc") are different queries from the cache's point of view — they have different argument tuples. So each page is cached as a separate entry. You can't watch(query: FeedQuery(...)) and see all pages merged.
Two solutions:
posts array doesn't auto-update.after argument when computing the cache key for that field, and append incoming edges to existing. More involved; only worth it if you need watchers across pages.For most apps, option 1 is fine.
Same idea, simpler:
query Feed($limit: Int!, $offset: Int!) {
feed(limit: $limit, offset: $offset) {
id
title
}
}
private var offset = 0
@MainActor
func loadMore() async {
let data = try? await Network.apollo.fetchAsync(query: FeedQuery(limit: 20, offset: offset))
if let new = data?.feed {
posts.append(contentsOf: new)
offset += new.count
}
}
Cursor-based is the modern preference but offset is fine for stable, infrequently-changing lists.
Subscriptions are streamed from the server over a long-lived connection. WebSocket is the standard transport.
You add a separate WebSocket transport and route subscriptions through it:
import Apollo
import ApolloAPI
import ApolloWebSocket
enum Network {
static let apollo: ApolloClient = {
let httpURL = URL(string: "https://api.example.com/graphql")!
let wsURL = URL(string: "wss://api.example.com/graphql")!
let store = ApolloStore()
let httpTransport = RequestChainNetworkTransport(
interceptorProvider: DefaultInterceptorProvider(store: store),
endpointURL: httpURL
)
let wsClient = WebSocket(url: wsURL, protocol: .graphql_ws)
let wsTransport = WebSocketTransport(websocket: wsClient)
let split = SplitNetworkTransport(
uploadingNetworkTransport: httpTransport,
webSocketNetworkTransport: wsTransport
)
return ApolloClient(networkTransport: split, store: store)
}()
}
SplitNetworkTransport routes queries and mutations over HTTP, subscriptions over WebSocket. Two protocols are common: graphql_ws (older, also called "subscriptions-transport-ws") and graphql_transport_ws (newer, called "graphql-ws"). Match what your server speaks. Modern servers prefer graphql_transport_ws.
subscription PostAdded($authorId: ID) {
postAdded(authorId: $authorId) {
id
title
publishedAt
author { id name }
}
}
After codegen, the call site:
let cancellable = Network.apollo.subscribe(
subscription: PostAddedSubscription(authorId: GraphQLNullable(optional: nil))
) { result in
switch result {
case .success(let response):
if let newPost = response.data?.postAdded {
// Handle new post
}
case .failure(let error):
print("Subscription error:", error)
}
}
// To stop subscribing:
cancellable.cancel()
The callback API doesn't compose well with structured concurrency. Wrap it in an AsyncThrowingStream:
func observeNewPosts(authorId: String?) -> AsyncThrowingStream<PostAddedSubscription.Data, Error> {
AsyncThrowingStream { continuation in
let cancellable = Network.apollo.subscribe(
subscription: PostAddedSubscription(authorId: GraphQLNullable(optional: authorId))
) { result in
switch result {
case .success(let response):
if let data = response.data {
continuation.yield(data)
} else if let firstError = response.errors?.first {
continuation.finish(throwing: firstError)
}
case .failure(let error):
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in
cancellable.cancel()
}
}
}
Now consume it from SwiftUI:
.task {
do {
for try await update in observeNewPosts(authorId: nil) {
posts.insert(update.postAdded, at: 0)
}
} catch {
print("subscription ended:", error)
}
}
When the view's task is cancelled (view disappears, etc.), the onTermination closure runs, cancelling the subscription. Clean lifecycle, no leaks.
connection_init payload with credentials when the WebSocket opens. WebSocketTransport accepts a connectingPayload — pass auth tokens there.GraphQL errors live at three levels. You handle each differently.
The HTTP request itself failed. DNS error, no internet, TLS failure, server returned 500. These come back as URLError from URLSession. Treat them like any iOS network error: show retry UI, check connectivity, exponential backoff.
do {
let data = try await Network.apollo.fetchAsync(query: ...)
} catch let urlError as URLError {
// Network-level problem
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost:
// Show "no connection" UI
case .timedOut:
// Retry
default:
// Generic
}
}
The HTTP request succeeded (status 200), but the GraphQL response has an errors array. Each GraphQLError has at minimum a message, often a path, and an extensions object the server uses for typed error codes.
A commonly-used (but unspecified) convention is extensions.code for machine-readable error categories: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT. Coordinate with backend on the codes used.
do {
let data = try await Network.apollo.fetchAsync(query: ...)
} catch let gqlError as GraphQLError {
if let code = gqlError.extensions?["code"] as? String {
switch code {
case "UNAUTHENTICATED":
// Trigger re-login flow
case "FORBIDDEN":
// Show "no access" UI
case "NOT_FOUND":
// Empty state
default:
// Generic
}
}
}
This is the one REST veterans miss. A GraphQL response can have both data and errors:
{
"data": {
"user": { "id": "1", "name": "Ada", "email": null }
},
"errors": [
{ "message": "Email service timed out", "path": ["user", "email"] }
]
}
The server resolved name successfully but couldn't resolve email. So it returned what it had and reported the failure. This is valid and often desirable — the user's profile screen can render without the email field.
Our async wrapper from Chapter 12 throws on the first error, losing partial data. For partial-success-tolerant screens, use the lower-level callback API:
func fetchUserAllowingPartial(id: String) async throws -> (user: GetUserQuery.Data.User?, errors: [GraphQLError]) {
try await withCheckedThrowingContinuation { continuation in
Network.apollo.fetch(query: GetUserQuery(id: id)) { result in
switch result {
case .success(let response):
continuation.resume(returning: (response.data?.user, response.errors ?? []))
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
The caller decides what to do with the partial result. For a profile screen, render what you have and show a banner "couldn't load some fields." For a transactional flow (creating an order), partial data probably means failure — surface it as such.
Production logging should capture, for every error:
extensions.codeA small helper:
func logGraphQLError(_ error: GraphQLError, operationName: String) {
let code = (error.extensions?["code"] as? String) ?? "UNKNOWN"
let path = error.path?.map(\.description).joined(separator: ".") ?? "—"
Analytics.shared.log(
event: "graphql_error",
properties: [
"operation": operationName,
"code": code,
"path": path,
"message": error.message ?? "",
]
)
}
In your APM, build dashboards keyed by operation × code. You'll spot regressions per-screen.
Built-in scalars (Int, Float, String, Boolean, ID) are five. Anything else is custom — DateTime, URL, UUID, JSON, Decimal, BigInt, etc.
By default, Apollo treats unknown scalars as strings. To map them to richer Swift types, configure the codegen.
In apollo-codegen-config.json:
"options": {
"schemaCustomization": {
"customTypeNames": {
"DateTime": { "typeName": "Foundation.Date" },
"URL": { "typeName": "Foundation.URL" },
"UUID": { "typeName": "Foundation.UUID" }
}
}
}
Then provide an extension that conforms Date (and friends) to Apollo's CustomScalarType:
import ApolloAPI
import Foundation
extension Date: @retroactive CustomScalarType {
public init(_jsonValue value: JSONValue) throws {
guard let string = value as? String,
let date = ISO8601DateFormatter().date(from: string) else {
throw JSONDecodingError.couldNotConvert(value: value, to: Date.self)
}
self = date
}
public var _jsonValue: JSONValue {
ISO8601DateFormatter().string(from: self)
}
}
After regenerating, fields typed DateTime in the schema appear as Date in your Swift types. No more parsing in view models.
For server formats other than ISO-8601, replace ISO8601DateFormatter() with your matching parser.
GraphQL is JSON over HTTP — files don't fit. The community GraphQL multipart request spec bolts file uploads onto GraphQL using multipart/form-data.
The schema declares an Upload scalar:
scalar Upload
type Mutation {
uploadAvatar(file: Upload!): User!
}
Client side:
import Apollo
import ApolloAPI
guard let imageData = UIImage(...).jpegData(compressionQuality: 0.8) else { return }
let upload = GraphQLFile(
fieldName: "file",
originalName: "avatar.jpg",
mimeType: "image/jpeg",
data: imageData
)
Network.apollo.upload(
operation: UploadAvatarMutation(file: "file"),
files: [upload]
) { result in
// ...
}
A few things to notice. The mutation's file variable receives the field name string ("file"), not the bytes. The actual bytes go in the files: array as GraphQLFile instances. Apollo composes the multipart request per spec.
This works only if your server speaks the multipart spec. Many backends prefer a separate non-GraphQL endpoint for uploads (POST /upload returns a URL), and the client passes that URL into a regular GraphQL mutation. Coordinate with backend.
Three layers of testing, each with a different approach.
The cleanest pattern: depend on a service interface, not on ApolloClient directly.
protocol CountriesServicing {
func fetchCountries() async throws -> [GetCountriesQuery.Data.Country]
}
final class ApolloCountriesService: CountriesServicing {
func fetchCountries() async throws -> [GetCountriesQuery.Data.Country] {
let data = try await Network.apollo.fetchAsync(query: GetCountriesQuery())
return data.countries
}
}
final class FakeCountriesService: CountriesServicing {
var stubbedCountries: [GetCountriesQuery.Data.Country] = []
var stubbedError: Error?
func fetchCountries() async throws -> [GetCountriesQuery.Data.Country] {
if let error = stubbedError { throw error }
return stubbedCountries
}
}
In tests, hand the view model a FakeCountriesService with stubbed data. Verify state transitions.
To construct stubbed GetCountriesQuery.Data.Country values, use the memberwise init — this is why selectionSetInitializers.operations: true is non-negotiable in the codegen config:
let canada = GetCountriesQuery.Data.Country(
code: "CA",
name: "Canada",
emoji: "🇨🇦",
capital: "Ottawa",
continent: .init(name: "North America")
)
MockNetworkTransportWhen you genuinely want to test the cache + interceptor pipeline together, Apollo has MockNetworkTransport for stubbing responses at the transport level. Set it up in the codegen config:
"output": {
"testMocks": { "swiftPackage": { "targetName": "CountriesGraphQLTestMocks" } },
...
}
Regenerate. A new SPM target appears. Add it to your test target. Now you can write tests that exercise the full Apollo stack with stubbed responses — useful for testing cache behavior, interceptors, error handling. Heavier setup; reach for it when service-level fakes don't cover the case.
If your team is paranoid about query drift, snapshot-test the generated query strings:
import Testing
@Test func GetCountriesQuery_documentIsStable() {
let document = GetCountriesQuery().__operationDefinition // accessor varies by Apollo version
// Compare against a saved snapshot
}
A schema rename should change generated code, change the snapshot, and force a code review. Niche but valuable for libraries with strict API stability.
@Observable IntegrationYou want SwiftUI screens that watch queries, update reactively, and clean up after themselves. Here's a reusable @Observable wrapper:
import Apollo
import ApolloAPI
import Observation
import SwiftUI
@Observable
final class GraphQLQueryObserver<Query: GraphQLQuery> {
enum State {
case idle
case loading
case loaded(Query.Data)
case failed(Error)
}
private(set) var state: State = .idle
@ObservationIgnored private var watcher: GraphQLQueryWatcher<Query>?
@ObservationIgnored private let client: ApolloClient
init(client: ApolloClient = Network.apollo) {
self.client = client
}
func start(query: Query, cachePolicy: CachePolicy = .returnCacheDataAndFetch) {
state = .loading
watcher = client.watch(query: query, cachePolicy: cachePolicy) { [weak self] result in
Task { @MainActor in
guard let self else { return }
switch result {
case .success(let response):
if let data = response.data {
self.state = .loaded(data)
} else if let firstError = response.errors?.first {
self.state = .failed(firstError)
}
case .failure(let error):
self.state = .failed(error)
}
}
}
}
func cancel() {
watcher?.cancel()
watcher = nil
}
deinit {
watcher?.cancel()
}
}
Use it in a screen:
struct CountriesScreen: View {
@State private var observer = GraphQLQueryObserver<GetCountriesQuery>()
var body: some View {
Group {
switch observer.state {
case .idle, .loading:
ProgressView()
case .loaded(let data):
List(data.countries, id: \.code) { country in
CountryCell(country: country.fragments.countrySummary)
}
case .failed(let error):
ContentUnavailableView(
"Couldn't load countries",
systemImage: "exclamationmark.triangle",
description: Text(error.localizedDescription)
)
}
}
.task {
observer.start(query: GetCountriesQuery())
}
.onDisappear { observer.cancel() }
}
}
The task modifier kicks off the watch when the view appears. onDisappear cancels it. Clean lifecycle, automatic cache-driven updates, three-state UI in a few dozen lines.
For mutations from SwiftUI, prefer a dedicated view model that owns the loading and error state:
@Observable
final class LikeButtonModel {
var isPending = false
var lastError: Error?
@MainActor
func toggle(post: Post) async {
isPending = true
defer { isPending = false }
do {
try await Network.apollo.performAsync(mutation: LikePostMutation(id: post.id))
} catch {
lastError = error
}
}
}
In the view:
struct LikeButton: View {
let post: Post
@State private var model = LikeButtonModel()
var body: some View {
Button {
Task { await model.toggle(post: post) }
} label: {
Image(systemName: post.isLikedByMe ? "heart.fill" : "heart")
}
.disabled(model.isPending)
}
}
Combine with the optimistic update pattern from Chapter 25 and the heart flips instantly on tap.
The casual Network.apollo.fetchAsync(...) calls scattered through this tutorial don't scale. Production iOS apps need:
The standard answer: Repository pattern.
Generated GetCountriesQuery.Data.Country is fine for narrow code. For shared business logic, define your own:
struct Country: Equatable, Identifiable {
let id: String // ISO code
let name: String
let emoji: String
let capital: String?
let continentName: String
init(_ generated: CountrySummary) {
self.id = generated.code
self.name = generated.name
self.emoji = generated.emoji
self.capital = generated.capital
self.continentName = generated.continent.name
}
}
The mapping is dull but valuable. View models, business logic, and other repositories speak Country, not GetCountriesQuery.Data.Country.
protocol CountryRepository {
func countries() async throws -> [Country]
func country(code: String) async throws -> Country?
}
final class ApolloCountryRepository: CountryRepository {
private let client: ApolloClient
init(client: ApolloClient) {
self.client = client
}
func countries() async throws -> [Country] {
let data = try await client.fetchAsync(query: GetCountriesQuery())
return data.countries.map { Country($0.fragments.countrySummary) }
}
func country(code: String) async throws -> Country? {
let data = try await client.fetchAsync(query: GetCountryDetailsQuery(code: ID(code)))
return data.country.map { Country($0.fragments.countrySummary) }
}
}
private struct CountryRepositoryKey: EnvironmentKey {
static let defaultValue: any CountryRepository = ApolloCountryRepository(client: Network.apollo)
}
extension EnvironmentValues {
var countryRepository: any CountryRepository {
get { self[CountryRepositoryKey.self] }
set { self[CountryRepositoryKey.self] = newValue }
}
}
Inject from views:
struct CountriesScreen: View {
@Environment(\.countryRepository) private var repo
@State private var countries: [Country] = []
var body: some View {
List(countries) { country in
// ...
}
.task {
countries = (try? await repo.countries()) ?? []
}
}
}
For tests, override in previews and unit tests:
#Preview {
CountriesScreen()
.environment(\.countryRepository, FakeCountryRepository(stubbed: [.canada, .france]))
}
This pattern gives you:
The cost: a mapping layer between generated types and domain types. For most production apps, paying it is unambiguously the right call. For tiny apps, you might skip it and use generated types directly. Your call.
In rough order of impact:
1. Use the cache properly. .returnCacheDataAndFetch for primary screens. Fragments to maximize cache reuse. Always select id (or your cache key) on every typed object. This single set of habits is worth more than every other optimization combined.
2. Persist the cache to SQLite. Cold-start time on data-heavy screens drops dramatically. Trivial to enable (Chapter 23).
3. Persisted queries. A production-grade optimization: Apollo can hash your queries at build time and send only the hash on the wire. The server resolves the hash from a registry. Saves bytes, prevents arbitrary queries from being sent, makes caching at CDN level possible. Configure with the operationManifest option in your codegen config and a server registry. Worth doing for high-traffic apps.
4. Profile with Instruments. Large GraphQL responses can be a JSON parse hotspot. If you see spikes during decoding, paginate harder or move work off the main thread (using actors as we have throughout).
5. Batch operations. Apollo's default transport doesn't batch, but BatchedNetworkTransport exists. Useful when one screen fires 5+ small queries in parallel — you'd consolidate them into one HTTP request.
A summary of the most common mistakes, gathered for reference:
Forgetting id (or your cache key) in selection sets. Cache normalization breaks; you get per-query duplicates. Add the cache key field to every fragment.
Anonymous operations. query { ... } without a name. Server logs lose visibility. Code gen breaks. Always name operations.
Forgetting to cancel watchers. Every client.watch returns a GraphQLQueryWatcher that you must cancel(). Use the @Observable wrapper from Chapter 33 so cancellation is in one place.
GraphQLNullable confusion. The three cases (some, null, none) take ten minutes to internalize. Once internalized, this stops biting.
Treating partial responses as errors. You lose useful data. Decide per-query whether partial-data is acceptable; fall back to the lower-level callback API when it is.
Logging tokens or PII. Easy to do accidentally with a logging interceptor that prints request headers. Sanitize before logging.
Not running codegen in CI. Ship a build with stale generated code, ship a bug. Either commit generated code and run codegen as a CI check, or run codegen as a build phase and trust it.
Mutating the cache without first ensuring it's populated. transaction.update(query:) throws if the cache miss. Either ensure the query has been observed before mutating, or catch the missing-data error.
Using [] argument syntax wrong. arguments: ["filter": .null] in generated code is Apollo's runtime representation, not Swift literal syntax. You don't write this — codegen does. Mention here just so it doesn't surprise you in stack traces.
Schema introspection disabled in production. Many backends disable introspection in prod for security. If you can't fetch-schema against prod, fetch against staging, or get the SDL file directly from the backend team and check it into the repo.
Server returns null where you expected a value. This is almost always a nullability mismatch — the schema says nullable, but you assumed non-null. Re-check the schema. If the schema is wrong, push for a fix.
Argument — A value passed to a field. country(code: "CA") — code is the argument name, "CA" is the value.
Cache normalization — Storing entities once by identity, with references between them. The opposite of storing nested copies.
Cache policy — Apollo's per-fetch knob that decides how the cache and network interact. Five values (Chapter 21).
Codegen / Code generation — The build-time process where Apollo's CLI reads your schema and .graphql files and emits Swift types.
Document — A .graphql file. Can contain multiple operations and fragments.
Field — A property of a type. name: String! declares a field name of type non-null String.
Fragment — A named, reusable selection set on a specific type. The composition primitive of GraphQL.
Inline fragment — ... on User { ... } syntax used inside a selection set on a union or interface to discriminate by concrete type.
Input type — A type used for complex arguments. Declared with input keyword.
Interceptor — Apollo's middleware hook. Runs on every request; can modify request/response.
Introspection — A built-in GraphQL query (__schema) that returns the full schema. How tools (and Apollo's CLI) discover what an API exposes.
Mutation — Operation type for writes. Server runs fields serially.
Non-null — ! after a type. The server guarantees this is never null.
Normalized cache — See "cache normalization."
Operation — A query, mutation, or subscription. Top-level executable unit.
Operation document — The full text of an operation as it goes over the wire.
Optimistic update — Writing a guess at the result to the cache before the server responds. UI updates instantly; reconciles when the server replies.
Persisted query — A registered query identified by hash; client sends just the hash.
Query — Operation type for reads. Side-effect-free, parallelizable, cacheable.
Resolver — Server-side function that produces a field's value. You only deal with these as a server author; clients don't see them.
Schema — The full type contract of a GraphQL server, written in SDL.
SDL (Schema Definition Language) — The text format used to write schemas.
Selection set — The { ... } after a field, listing which fields you want on that object.
Subscription — Operation type for streams. Long-lived; typically over WebSocket.
__typename — A magic field every type exposes that returns the runtime concrete type name. Used by Apollo for cache normalization and union/interface discrimination.
Variable — A typed input to an operation, prefixed with $. Sent separately from the query string.
Watcher — Apollo's observable query handle. Fires every time the cached data for a query changes.
You now have everything you need to build production GraphQL-powered iOS apps. Start small — wire up Apollo, run one query, render a screen — and reach for the more advanced patterns (subscriptions, optimistic updates, custom interceptors) as your app's complexity demands them. Happy querying.